package springinpractice.ch8.mailinglist.service;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;

import org.apache.commons.codec.digest.DigestUtils;
import org.apache.log4j.Logger;
import org.apache.velocity.app.VelocityEngine;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.velocity.VelocityEngineUtils;
import org.springframework.util.Assert;

import springinpractice.ch8.mailinglist.dao.SubscriberDao;
import springinpractice.ch8.mailinglist.model.Subscriber;

/*
 * Author       :       Willie Wheeler and John Wheeler
 * Book         :       'Spring In Practice' [Book written by Willie Wheeler and John Wheeler]
 */

@Service
@Transactional(
	propagation = Propagation.REQUIRED,
	isolation = Isolation.DEFAULT,
	readOnly = true)
public class MailingListServiceImpl implements MailingListService {
	private static final String SUBSCRIBE_TEMPLATE_PATH = "mailingListSubscribe.vm";
	private static final String UNSUBSCRIBE_TEMPLATE_PATH = "mailingListUnsubscribe.vm";
	
	private static final long ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
	private static final Logger log = Logger.getLogger(MailingListServiceImpl.class);
	
	private SubscriberDao subscriberDao;
	private JavaMailSender mailSender;
	private VelocityEngine velocityEngine;
	
	private String noReplyEmailAddress;
	private String confirmSubscriptionUrl;
	private String confirmUnsubscriptionUrl;
	private String confirmationKey;
	
	
	// ========================================================================
	// Dependency injection methods
	// ========================================================================
	
	public void setSubscriberDao(SubscriberDao subscriberDao) {
		this.subscriberDao = subscriberDao;
	}
	
	public void setMailSender(JavaMailSender mailSender) {
		this.mailSender = mailSender;
	}
	
	public void setVelocityEngine(VelocityEngine velocityEngine) {
		this.velocityEngine = velocityEngine;
	}
	
	@Required
	public void setNoReplyEmailAddress(String noReplyEmailAddress) {
		this.noReplyEmailAddress = noReplyEmailAddress;
	}
	
	@Required
	public void setConfirmSubscriptionUrl(String confirmSubscriptionUrl) {
		this.confirmSubscriptionUrl = confirmSubscriptionUrl;
	}
	
	@Required
	public void setConfirmUnsubscriptionUrl(String confirmUnsubscriptionUrl) {
		this.confirmUnsubscriptionUrl = confirmUnsubscriptionUrl;
	}
	
	@Required
	public void setConfirmationKey(String confirmationKey) {
		this.confirmationKey = confirmationKey;
	}
	
	
	// ========================================================================
	// General service methods
	// ========================================================================
	
	public Subscriber getSubscriber(long id) {
		Assert.isTrue(id > 0L);
		return subscriberDao.load(id);
	}
	
	
	// ========================================================================
	// Subscription service methods
	// ========================================================================
	
	@Transactional(readOnly = false)
	public void addSubscriber(Subscriber subscriber) {
		Assert.notNull(subscriber, "subscriber required");
		Assert.isTrue(!subscriber.isConfirmed(), "subscriber must be unconfirmed");
		Assert.notNull(subscriber.getIpAddress(), "subscriber.ipAddress required");
		Assert.notNull(subscriber.getDateCreated(), "subscriber.dateCreated required");
		
		log.info("Adding unconfirmed subscriber: " + subscriber);
		subscriberDao.save(subscriber);
		sendConfirmSubscriptionEmail(subscriber);
	}
	
	private void sendConfirmSubscriptionEmail(Subscriber subscriber) {
		assert (subscriber != null);
		log.info("Sending confirm subscription e-mail to: " + subscriber);
		
		MimeMessage message = mailSender.createMimeMessage();
		MimeMessageHelper helper = new MimeMessageHelper(message);
		
		String digest = generateSubscriptionDigest(subscriber);
		String url = confirmSubscriptionUrl +
			"?s=" + subscriber.getId() + "&amp;d=" + digest;
		
		Map<String, Object> model = new HashMap<String, Object>();
		model.put("subscriber", subscriber);
		model.put("url", url);
		
		String text = VelocityEngineUtils.mergeTemplateIntoString(
			velocityEngine, SUBSCRIBE_TEMPLATE_PATH, model);
		
		try {
			helper.setSubject("Please confirm your subscription");
			helper.setTo(subscriber.getEmail());
			helper.setFrom(noReplyEmailAddress);
			helper.setSentDate(subscriber.getDateCreated());
			helper.setText(text, true);
		} catch (MessagingException e) {
			throw new RuntimeException(e);
		}
		
		mailSender.send(message);
	}
	
	@Transactional(readOnly = false)
	public void confirmSubscriber(long subscriberId, String digest)
		throws ConfirmationFailedException {
		
		Assert.isTrue(subscriberId > 0L);
		Assert.notNull(digest);
		
		Subscriber subscriber = getSubscriber(subscriberId);
		
		// Check timestamp first to avoid SHA-based DoS.
		checkTimestamp(subscriber.getDateCreated().getTime());
		
		// Now generate the digest and check for a match.
		String expectedDigest = generateSubscriptionDigest(subscriber);
		if (!digest.equals(expectedDigest)) {
			throw new ConfirmationFailedException("Bad digest");
		}
		
		// All good, confirm it
		log.info("Confirming subscriber: " + subscriber);
		subscriber.setConfirmed(true);
		subscriber.setEnabled(true);
		subscriberDao.update(subscriber);
	}
	
	private String generateSubscriptionDigest(Subscriber subscriber) {
		return DigestUtils.shaHex(subscriber.getId() + ":" + confirmationKey);
	}
	
	
	// ========================================================================
	// Unsubscription service methods
	// ========================================================================
	
	@Transactional(readOnly = false)
	public void requestUnsubscription(String email) {
		Assert.notNull(email);
		sendConfirmUnsubscriptionEmail(email);
	}
	
	private void sendConfirmUnsubscriptionEmail(String email) {
		assert (email != null);
		log.info("Sending confirm unsubscription e-mail to: " + email);
		
		MimeMessage message = mailSender.createMimeMessage();
		MimeMessageHelper helper = new MimeMessageHelper(message);
		
		Date now = new Date();
		long timestamp = now.getTime();
		
		String digest = generateUnsubscriptionDigest(email, timestamp);
		String url = confirmUnsubscriptionUrl +
			"?e=" + email + "&amp;t=" + timestamp + "&amp;d=" + digest;
		
		Map<String, Object> model = new HashMap<String, Object>();
		model.put("url", url);
		
		String text = VelocityEngineUtils.mergeTemplateIntoString(
			velocityEngine, UNSUBSCRIBE_TEMPLATE_PATH, model);
		
		try {
			helper.setSubject("Please confirm your unsubscription");
			helper.setTo(email);
			helper.setFrom(noReplyEmailAddress);
			helper.setSentDate(now);
			helper.setText(text, true);
		} catch (MessagingException e) {
			throw new RuntimeException(e);
		}
		
		mailSender.send(message);
	}
	
	@Transactional(readOnly = false)
	public void confirmUnsubscription(String email, long timestamp, String digest)
		throws ConfirmationFailedException {
		
		Assert.notNull(email, "email can't be null");
		Assert.notNull(digest, "digest required");
		
		// Check timestamp first to avoid SHA-based DoS.
		checkTimestamp(timestamp);
		
		// Now generate the digest and check for a match.
		String expectedDigest = generateUnsubscriptionDigest(email, timestamp);
		if (!digest.equals(expectedDigest)) {
			throw new ConfirmationFailedException("Bad digest");
		}
		
		// All good, confirm it
		log.info("Confirming unsubscription for: " + email);
		subscriberDao.disableAllByEmail(email);
	}
	
	private String generateUnsubscriptionDigest(String email, long timestamp) {
		return DigestUtils.shaHex(email + ":" + timestamp + ":" + confirmationKey);
	}
	
	
	// ========================================================================
	// Helper methods
	// ========================================================================
	
	private static void checkTimestamp(long timestamp) throws ConfirmationExpiredException {
		long now = System.currentTimeMillis();
		if (now - timestamp > ONE_DAY_IN_MS) {
			throw new ConfirmationExpiredException();
		}
	}
}
